🔧
Frimay
Gestión de partes de trabajo
¿Olvidaste tu contraseña?
Frimay
Gestión de partes
Conectando...
Principal
Dashboard
Avisos 0
Partes 0
Factusol
Clientes
Artículos
Mapas
Mapa averías
Sistema
Configuración
Frimay © 2025v1.0
Dashboard
Vista general
⚙️
Iniciando...
Inicio
0
Avisos
0
Partes
Clientes
Artículos
Mapa
({ parte_id: parte.id, codigo: r.codigo, nombre: r.nombre, precio: r.precio, cantidad: r.cantidad || 1, })) ); } // 3. Mano de obra if (parteData.manoObra?.length) { await db.from('parte_mano_obra').insert( parteData.manoObra.map(m => ({ parte_id: parte.id, tipo: m.tipo, horas: m.horas, precio: m.precio, })) ); } // 4. Subir firma si existe if (parteData.firma) { const firmaUrl = await subirFirma(parte.id, parteData.firma); await db.from('partes').update({ firma_url: firmaUrl }).eq('id', parte.id); } // 5. Subir fotos if (parteData.fotosCliente?.length) { await subirFotos(parte.id, null, parteData.fotosCliente, 'cliente'); } return parte.id; }, async actualizar(id, parteData) { // Actualizar parte const { error } = await db.from('partes').update({ cliente_cod: parteData.clienteCod, cliente_nombre: parteData.clienteNombre, equipo_nombre: parteData.equipoNombre, tecnico_nombre: parteData.tecnico, fecha: parteData.fecha, tipo: parteData.tipo, problema: parteData.problema, trabajos: parteData.trabajos, desplazamiento: parteData.desplazamiento || 0, km: parteData.km || 0, obs: parteData.obs, estado: parteData.estado, firma_geo: parteData.firmaGeo || null, }).eq('id', id); if (error) throw error; // Reemplazar repuestos y mano de obra await db.from('parte_repuestos').delete().eq('parte_id', id); await db.from('parte_mano_obra').delete().eq('parte_id', id); if (parteData.repuestos?.length) { await db.from('parte_repuestos').insert( parteData.repuestos.map(r => ({ parte_id: id, codigo: r.codigo, nombre: r.nombre, precio: r.precio, cantidad: r.cantidad || 1 })) ); } if (parteData.manoObra?.length) { await db.from('parte_mano_obra').insert( parteData.manoObra.map(m => ({ parte_id: id, tipo: m.tipo, horas: m.horas, precio: m.precio })) ); } // Actualizar firma si cambió if (parteData.firma && parteData.firma.startsWith('data:')) { const firmaUrl = await subirFirma(id, parteData.firma); await db.from('partes').update({ firma_url: firmaUrl }).eq('id', id); } }, async eliminar(id) { await db.from('partes').delete().eq('id', id); }, async cambiarEstado(id, estado) { await db.from('partes').update({ estado }).eq('id', id); } }; // ── AVISOS ─────────────────────────────────────────────────────── const SupaAvisos = { async getAll() { const { data, error } = await db.from('avisos') .select(`*, fotos(*)`) .order('numero', { ascending: false }); if (error) throw error; return data.map(mapAvisoDeBD); }, async crear(avisoData) { const { data, error } = await db.from('avisos').insert({ cliente_cod: avisoData.clienteCod, cliente_nombre: avisoData.clienteNombre, equipo_id: avisoData.equipoId || null, equipo_nombre: avisoData.equipoNombre, tecnico_id: currentUsuario?.id || null, tecnico_nombre: avisoData.tecnico, fecha: avisoData.fecha, problema: avisoData.problema, estado: avisoData.estado || 'pendiente', }).select().single(); if (error) throw error; // Subir fotos del aviso if (avisoData.fotosCliente?.length) { await subirFotos(null, data.id, avisoData.fotosCliente, 'cliente'); } return data.id; }, async actualizar(id, avisoData) { const { error } = await db.from('avisos').update({ cliente_cod: avisoData.clienteCod, cliente_nombre: avisoData.clienteNombre, equipo_nombre: avisoData.equipoNombre, tecnico_nombre: avisoData.tecnico, fecha: avisoData.fecha, problema: avisoData.problema, estado: avisoData.estado, }).eq('id', id); if (error) throw error; }, async eliminar(id) { await db.from('avisos').delete().eq('id', id); }, async vincularParte(avisoId, parteId, nuevoEstado) { await db.from('avisos').update({ parte_id: parteId, estado: nuevoEstado }).eq('id', avisoId); } }; // ── EQUIPOS ────────────────────────────────────────────────────── const SupaEquipos = { async getPorCliente(clienteCod) { const { data } = await db.from('equipos') .select('*').eq('cliente_cod', clienteCod).eq('activo', true); return data || []; }, async crear(clienteCod, eq) { let fotoUrl = null; if (eq.foto && eq.foto.startsWith('data:')) { fotoUrl = await subirFotoEquipo(eq.foto); } const { data, error } = await db.from('equipos').insert({ cliente_cod: clienteCod, nombre: eq.nombre, marca: eq.marca, modelo: eq.modelo, serie: eq.serie, anio: eq.año ? parseInt(eq.año) : null, ubicacion: eq.ubic, obs: eq.obs, foto_url: fotoUrl, }).select().single(); if (error) throw error; return data; }, async eliminar(id) { await db.from('equipos').update({ activo: false }).eq('id', id); } }; // ── USUARIOS ───────────────────────────────────────────────────── const SupaUsuarios = { async getAll() { const { data } = await db.from('usuarios').select('*').eq('activo', true); return data || []; }, async crear(email, password, nombre, rol = 'tecnico') { // 1. Crear en Supabase Auth const { data: authData, error: authErr } = await db.auth.admin.createUser({ email, password, email_confirm: true }); if (authErr) throw authErr; // 2. Insertar en nuestra tabla const { data, error } = await db.from('usuarios').insert({ auth_id: authData.user.id, nombre, email, rol }).select().single(); if (error) throw error; return data; } }; // ── CONFIG ─────────────────────────────────────────────────────── const SupaConfig = { async getAll() { const { data } = await db.from('config').select('*'); const cfg = {}; (data || []).forEach(r => { cfg[r.clave] = r.valor; }); return cfg; }, async set(clave, valor) { await db.from('config').upsert({ clave, valor }); localStorage.setItem(clave, valor); // cache local } }; // ── STORAGE: subir archivos ─────────────────────────────────────── async function subirFirma(parteId, dataUrl) { const blob = dataURLtoBlob(dataUrl); const path = `firmas/${parteId}.png`; const { error } = await db.storage.from('firmas').upload(path, blob, { contentType: 'image/png', upsert: true }); if (error) throw error; const { data } = db.storage.from('firmas').getPublicUrl(path); return data.publicUrl; } async function subirFotos(parteId, avisoId, fotos, tipo) { for (const foto of fotos) { if (!foto.url || !foto.url.startsWith('data:')) continue; const blob = dataURLtoBlob(foto.url); const ext = foto.name?.split('.').pop() || 'jpg'; const id = parteId || avisoId; const path = `fotos/${tipo}/${id}/${Date.now()}.${ext}`; const { error } = await db.storage.from('fotos').upload(path, blob, { contentType: blob.type, upsert: false }); if (error) { console.warn('Error subiendo foto:', error); continue; } const { data } = db.storage.from('fotos').getPublicUrl(path); // Registrar en tabla fotos await db.from('fotos').insert({ parte_id: parteId || null, aviso_id: avisoId || null, tipo, url: data.publicUrl, nombre: foto.name }); } } async function subirFotoEquipo(dataUrl) { const blob = dataURLtoBlob(dataUrl); const path = `equipos/${Date.now()}.jpg`; const { error } = await db.storage.from('equipos').upload(path, blob, { contentType: 'image/jpeg', upsert: false }); if (error) return null; const { data } = db.storage.from('equipos').getPublicUrl(path); return data.publicUrl; } // ── HELPERS ────────────────────────────────────────────────────── function dataURLtoBlob(dataUrl) { const [header, data] = dataUrl.split(','); const mime = header.match(/:(.*?);/)[1]; const binary = atob(data); const arr = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) arr[i] = binary.charCodeAt(i); return new Blob([arr], { type: mime }); } // Convertir formato BD → formato app function mapParteDeBD(p) { return { id: p.id, numero: p.numero, avisoId: p.aviso_id, clienteCod: p.cliente_cod, clienteNombre: p.cliente_nombre, equipoNombre: p.equipo_nombre, equipoId: p.equipo_id, tecnico: p.tecnico_nombre, fecha: p.fecha, tipo: p.tipo, problema: p.problema, trabajos: p.trabajos, repuestos: (p.parte_repuestos || []).map(r => ({ codigo: r.codigo, nombre: r.nombre, precio: r.precio, cantidad: r.cantidad })), manoObra: (p.parte_mano_obra || []).map(m => ({ tipo: m.tipo, horas: m.horas, precio: m.precio })), desplazamiento: p.desplazamiento, km: p.km, obs: p.obs, estado: p.estado, firma: p.firma_url, firmaGeo: p.firma_geo, fotosCliente: (p.fotos || []).filter(f => f.tipo === 'cliente').map(f => ({ url: f.url, name: f.nombre, type: 'img' })), fotosReparacion:(p.fotos || []).filter(f => f.tipo === 'reparacion').map(f => ({ url: f.url, name: f.nombre, type: 'img' })), }; } function mapAvisoDeBD(a) { return { id: a.id, numero: a.numero, clienteCod: a.cliente_cod, clienteNombre: a.cliente_nombre, equipoNombre: a.equipo_nombre, equipoId: a.equipo_id, tecnico: a.tecnico_nombre, fecha: a.fecha, problema: a.problema, estado: a.estado, parteId: a.parte_id, fotosCliente: (a.fotos || []).map(f => ({ url: f.url, name: f.nombre, type: 'img' })), }; } // ── SINCRONIZACIÓN OFFLINE ─────────────────────────────────────── const Sync = { PENDING_KEY: 'frimay_sync_pending', // Guardar operación pendiente cuando no hay conexión async guardarPendiente(tabla, accion, datos) { const pending = JSON.parse(localStorage.getItem(this.PENDING_KEY) || '[]'); pending.push({ tabla, accion, datos, ts: Date.now(), tempId: 'tmp_' + Date.now() }); localStorage.setItem(this.PENDING_KEY, JSON.stringify(pending)); }, // Sincronizar cuando vuelve la conexión async sincronizar() { const pending = JSON.parse(localStorage.getItem(this.PENDING_KEY) || '[]'); if (!pending.length) return; console.log(`Sincronizando ${pending.length} operaciones pendientes...`); const fallidas = []; for (const op of pending) { try { if (op.tabla === 'partes') { if (op.accion === 'insert') await SupaPartes.crear(op.datos); if (op.accion === 'update') await SupaPartes.actualizar(op.datos.id, op.datos); if (op.accion === 'delete') await SupaPartes.eliminar(op.datos.id); } if (op.tabla === 'avisos') { if (op.accion === 'insert') await SupaAvisos.crear(op.datos); if (op.accion === 'update') await SupaAvisos.actualizar(op.datos.id, op.datos); } } catch (e) { console.warn('Error sincronizando:', op, e); fallidas.push(op); } } localStorage.setItem(this.PENDING_KEY, JSON.stringify(fallidas)); if (!fallidas.length) { console.log('Sincronización completa ✓'); // Recargar datos frescos await cargarDatos(); } }, pendienteCount() { return JSON.parse(localStorage.getItem(this.PENDING_KEY) || '[]').length; } }; // Detectar cambios de conexión // ════════════════════════════════════════════════════════════════ // AUTENTICACIÓN Y ARRANQUE // ════════════════════════════════════════════════════════════════ const SUPA_URL="https://anfsucpowljqcnfzashq.supabase.co"; const SUPA_KEY="sb_publishable_SmHp6jDIEP_L9VsEet20eA_dJCibF79"; const {createClient}=supabase; const db=createClient(SUPA_URL,SUPA_KEY); // Detecta enlaces de recuperación de contraseña enviados por Supabase. db.auth.onAuthStateChange(async (event, session) => { if (event === 'PASSWORD_RECOVERY') { mostrarCambioPassword(); } }); let currentUser=null; let currentUsuario=null; function mostrarLogin(){ document.getElementById('login-screen').style.display='flex'; document.getElementById('main-app').style.display='none'; } function mostrarApp(){ document.getElementById('login-screen').style.display='none'; document.getElementById('main-app').style.display=''; if(currentUsuario){ const nm=document.getElementById('user-name'); const rl=document.getElementById('user-role'); if(nm) nm.textContent=currentUsuario.nombre||currentUser.email; if(rl) rl.textContent=currentUsuario.rol==='admin'?'Administrador':'Técnico'; } // Activar sincronización en tiempo real iniciarRealtime(); } async function hacerLogin(){ const email=document.getElementById('login-email').value.trim(); const pass=document.getElementById('login-pass').value; const btn=document.getElementById('login-btn'); const err=document.getElementById('login-error'); err.style.display='none'; btn.disabled=true; btn.textContent='Entrando...'; try{ if(!email) throw new Error('Introduce el email.'); if(!pass) throw new Error('Introduce la contraseña.'); const {data,error}=await db.auth.signInWithPassword({email,password:pass}); if(error) throw new Error(traducirAuthError(error.message)); currentUser=data.user; const {data:perfil,error:perfilError}=await db .from('usuarios') .select('*') .eq('auth_id',data.user.id) .single(); if(perfilError){ throw new Error('Login correcto, pero no encuentro tu perfil en la tabla usuarios. Revisa que exista auth_id = '+data.user.id+'. Detalle: '+perfilError.message); } if(!perfil){ throw new Error('Login correcto, pero no existe usuario interno vinculado en la tabla usuarios.'); } currentUsuario=perfil; mostrarApp(); try{ await cargarDatos(); }catch(e){ console.error('Error cargando datos:',e); alert('Has entrado, pero hay un error cargando datos: '+(e.message||e)); } }catch(e){ console.error('Error login:',e); err.textContent=e.message||'No se pudo iniciar sesión'; err.style.display='block'; btn.disabled=false; btn.textContent='Entrar'; } } function traducirAuthError(msg){ const m=String(msg||''); if(m.toLowerCase().includes('invalid login credentials')) return 'Email o contraseña incorrectos.'; if(m.toLowerCase().includes('email not confirmed')) return 'El email todavía no está confirmado en Supabase.'; return m; } async function resetPassword(){ const email=document.getElementById('login-email').value.trim(); const err=document.getElementById('login-error'); err.style.display='none'; try{ if(!email) throw new Error('Introduce tu email y vuelve a pulsar “¿Olvidaste tu contraseña?”.'); const {error}=await db.auth.resetPasswordForEmail(email,{ redirectTo: window.location.origin }); if(error) throw new Error(traducirAuthError(error.message)); err.style.display='block'; err.style.background='#f0fdf4'; err.style.borderColor='#bbf7d0'; err.style.color='#15803d'; err.textContent='Te he enviado un correo para cambiar la contraseña. Revisa también spam.'; }catch(e){ err.style.display='block'; err.style.background='#fef2f2'; err.style.borderColor='#fecaca'; err.style.color='#dc2626'; err.textContent=e.message||'No se pudo enviar el correo de recuperación.'; } } function mostrarCambioPassword(){ const login=document.getElementById('login-screen'); document.getElementById('main-app').style.display='none'; login.style.display='flex'; login.innerHTML=`
🔐
Nueva contraseña
Introduce y confirma tu nueva contraseña
`; } async function guardarNuevaPassword(){ const p1=document.getElementById('new-pass').value; const p2=document.getElementById('new-pass-2').value; const msg=document.getElementById('pass-msg'); msg.style.display='none'; try{ if(!p1 || p1.length<6) throw new Error('La contraseña debe tener al menos 6 caracteres.'); if(p1!==p2) throw new Error('Las contraseñas no coinciden.'); const {error}=await db.auth.updateUser({password:p1}); if(error) throw new Error(traducirAuthError(error.message)); msg.style.display='block'; msg.style.background='#f0fdf4'; msg.style.borderColor='#bbf7d0'; msg.style.color='#15803d'; msg.textContent='Contraseña actualizada correctamente. Ya puedes entrar.'; await db.auth.signOut(); setTimeout(()=>{ window.location.href=window.location.origin; },1500); }catch(e){ msg.style.display='block'; msg.style.background='#fef2f2'; msg.style.borderColor='#fecaca'; msg.style.color='#dc2626'; msg.textContent=e.message||'No se pudo cambiar la contraseña.'; } } async function logout(){ pararRealtime(); await db.auth.signOut(); currentUser=null; currentUsuario=null; mostrarLogin(); } // Soporte Enter en login document.addEventListener('DOMContentLoaded',function(){ document.getElementById('login-pass')?.addEventListener('keydown',function(e){ if(e.key==='Enter') hacerLogin(); }); }); // Arranque: verificar sesión existente (async function arranque(){ const esRecovery = window.location.hash.includes('type=recovery') || window.location.search.includes('type=recovery'); if(esRecovery){ // Supabase terminará de crear la sesión y lanzará PASSWORD_RECOVERY. setTimeout(()=>mostrarCambioPassword(),500); return; } const {data:{session}}=await db.auth.getSession(); if(session){ try{ currentUser=session.user; const {data:perfil,error:perfilError}=await db .from('usuarios') .select('*') .eq('auth_id',session.user.id) .single(); if(perfilError) throw perfilError; currentUsuario=perfil; mostrarApp(); await cargarDatos(); }catch(e){ console.error('Sesión existente, pero error cargando perfil/datos:',e); await db.auth.signOut(); mostrarLogin(); const err=document.getElementById('login-error'); if(err){ err.textContent='Había sesión iniciada, pero no se pudo cargar tu perfil. Revisa tabla usuarios/RLS. '+(e.message||''); err.style.display='block'; } } } else { mostrarLogin(); } })(); // Detectar entorno: Cloudflare usa proxy, local usa Railway directamente const RAILWAY_URL = 'https://frimaypartesdetrabajo-production.up.railway.app'; const ES_CLOUDFLARE = window.location.hostname.includes('pages.dev') || window.location.hostname.includes('frimaypartesdetrabajo'); const API = ES_CLOUDFLARE ? '' : RAILWAY_URL; console.log('API modo:', ES_CLOUDFLARE ? 'Cloudflare proxy' : 'Railway directo', '→', API||'/api/*'); let clientes=[],articulos=[]; // Datos en memoria (cargados desde Supabase) let partes=[]; let equipos={}; let currentPage='dashboard',apiOk=false; let parteNum=partes.length>0?Math.max(...partes.map(p=>parseInt(p.numero)||0)):0; let _cliBusq='',_artBusq='',_cliSel=null; // ── INDEXEDDB PARA FOTOS ──────────────────────────────────────── let _idb = null; function abrirIDB(){ return new Promise((res, rej)=>{ if(_idb){ res(_idb); return; } const req = indexedDB.open('frimay_fotos', 1); req.onupgradeneeded = e => { e.target.result.createObjectStore('fotos'); }; req.onsuccess = e => { _idb = e.target.result; res(_idb); }; req.onerror = () => rej(req.error); }); } async function guardarFotosIDB(parteId, fotos){ console.log('guardarFotosIDB:', parteId, fotos); if(!fotos || (!fotos.fotosCliente?.length && !fotos.fotosAviso?.length && !fotos.fotosReparacion?.length)){ console.log('Sin fotos que guardar'); return; } try { const db = await abrirIDB(); return new Promise((res, rej)=>{ const tx = db.transaction('fotos','readwrite'); tx.objectStore('fotos').put(JSON.stringify(fotos), parteId); tx.oncomplete = ()=>{ console.log('Fotos guardadas en IDB para',parteId); res(); }; tx.onerror = (e)=>{ console.error('IDB error al guardar:',e); rej(e); }; }); } catch(e){ console.warn('guardarFotosIDB error:',e); } } async function cargarFotosIDB(parteId){ try { const db = await abrirIDB(); return new Promise((res)=>{ const tx = db.transaction('fotos','readonly'); const req = tx.objectStore('fotos').get(parteId); req.onsuccess = () => { const fotos = req.result ? JSON.parse(req.result) : {}; console.log('cargarFotosIDB:', parteId, fotos); res(fotos); }; req.onerror = () => { console.warn('IDB error al cargar'); res({}); }; }); } catch(e){ console.warn('cargarFotosIDB error:',e); return {}; } } async function eliminarFotosIDB(parteId){ try { const db = await abrirIDB(); return new Promise((res)=>{ const tx = db.transaction('fotos','readwrite'); tx.objectStore('fotos').delete(parteId); tx.oncomplete = res; tx.onerror = res; }); } catch(e){} } let avisos=JSON.parse(localStorage.getItem('frimay_avisos')||'[]'); let avisoNum=avisos.length>0?Math.max(...avisos.map(a=>parseInt(a.numero)||0)):0; // savePartes - ahora usamos Supabase (se llama tras operaciones) const savePartes=()=>{}; // No-op: guardamos en Supabase directamente const saveAvisos=()=>{}; // No-op: guardamos en Supabase directamente const saveEquipos=()=>localStorage.setItem('frimay_equipos',JSON.stringify(equipos)); const ini=s=>(s||'??').split(' ').slice(0,2).map(w=>w[0]||'').join('').toUpperCase(); const fdate=d=>d?d.substring(0,10).split('-').reverse().join('/'):'—'; const calcTotal=p=>{ let t=0; (p.repuestos||[]).forEach(r=>t+=r.precio*(r.cantidad||1)); (p.manoObra||[]).forEach(m=>t+=m.horas*m.precio); return t+(parseFloat(p.desplazamiento)||0); }; // ════════════════════════════════════════════════════════════════ // SUPABASE REALTIME — sincronización automática // ════════════════════════════════════════════════════════════════ let _realtimeChannel = null; // Recarga rápida desde Supabase sin tocar Factusol let _recargarTimeout = null; async function _recargarDesdeSupa(){ // Debounce: esperar 500ms por si llegan varios cambios seguidos clearTimeout(_recargarTimeout); _recargarTimeout = setTimeout(async ()=>{ try{ const [partesDB, avisosDB] = await Promise.all([ SupaPartes.getAll(), SupaAvisos.getAll() ]); const prevPartes = partes.length; const prevAvisos = avisos.length; partes = partesDB; avisos = avisosDB; // Notificar si hay cambios if(partes.length > prevPartes){ const nuevo = partes[0]; _notificarCambio('Nuevo parte #'+nuevo.numero+' — '+(nuevo.clienteNombre||'')); } else if(avisos.length > prevAvisos){ const nuevo = avisos[0]; _notificarCambio('Nuevo aviso #'+nuevo.numero+' — '+(nuevo.clienteNombre||'')); } // Actualizar cache offline localStorage.setItem('frimay_partes_cache', JSON.stringify(partes)); localStorage.setItem('frimay_avisos_cache', JSON.stringify(avisos)); renderPage(); } catch(e){ console.warn('Error recargando desde Supabase:', e); } }, 500); } function iniciarRealtime(){ // Evitar canales duplicados if(_realtimeChannel) return; _realtimeChannel = db.channel('frimay-sync') // Cambios en partes .on('postgres_changes', { event: '*', schema: 'public', table: 'partes' }, (payload) => { console.log('Realtime partes:', payload.eventType); // Recargar todo desde Supabase _recargarDesdeSupa(); }) // Cambios en avisos .on('postgres_changes', { event: '*', schema: 'public', table: 'avisos' }, (payload) => { console.log('Realtime avisos:', payload.eventType); _recargarDesdeSupa(); }) .subscribe((status) => { console.log('Realtime status:', status); if(status === 'SUBSCRIBED'){ console.log('✓ Sincronización en tiempo real activa'); } }); } function pararRealtime(){ if(_realtimeChannel){ db.removeChannel(_realtimeChannel); _realtimeChannel = null; } } function _notificarCambio(mensaje){ // Toast de notificación const toast = document.createElement('div'); toast.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);' +'background:#1a1a18;color:#fff;padding:10px 18px;border-radius:99px;font-size:13px;' +'font-weight:500;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,.3);' +'display:flex;align-items:center;gap:8px;white-space:nowrap;' +'animation:fadeInUp .3s ease'; toast.innerHTML = '● ' + mensaje; document.body.appendChild(toast); setTimeout(() => toast.remove(), 3500); } // Normalizar artículos recibidos desde Factusol/API // Acepta distintos nombres de campos para evitar que la app descarte artículos válidos. function normalizarArticulosFactusol(lista){ if(!Array.isArray(lista)) return []; return lista.map(a=>{ const codigo = a.codigo ?? a.CODART ?? a.codart ?? a.Codigo ?? a.codigoArticulo ?? a.CodigoArticulo ?? a.codArticulo ?? a.referencia ?? a.Referencia ?? a.ref ?? a.REF ?? ; const descripcion = a.descripcion ?? a.DESART ?? a.desart ?? a.Descripcion ?? a.descripción ?? a.nombre ?? a.Nombre ?? a.descripcionArticulo ?? a.DescripcionArticulo ?? a.articulo ?? a.Articulo ?? ; const pvp = a.pvp ?? a.PVP ?? a.precio ?? a.Precio ?? a.precioVenta ?? a.PrecioVenta ?? a.tarifa ?? a.Tarifa ?? 0; const familia = a.familia ?? a.Familia ?? a.familia_nombre ?? a.FamiliaNombre ?? ; const imagen = a.imagen ?? a.Imagen ?? a.foto ?? a.Foto ?? a.url_imagen ?? a.urlImagen ?? a.image ?? ; return { ...a, codigo: String(codigo || ).trim(), descripcion: String(descripcion || ).trim(), pvp: Number(String(pvp || 0).replace(,, .)) || 0, familia, imagen }; }).filter(a=>a.codigo && a.descripcion); } // Forzar actualización de datos de Factusol async function actualizarFactusol(){ const btn=document.getElementById('btn-refresh'); if(btn){ btn.style.animation='spin 1s linear infinite'; btn.disabled=true; } setPill('loading','Actualizando...'); // Limpiar caché para forzar recarga completa localStorage.removeItem('factusol_cache_ts'); try{ const [rc,ra]=await Promise.all([ fetch(API+'/api/clientes',{mode:'cors'}).then(r=>r.json()).catch(()=>({ok:false})), fetch(API+'/api/articulos',{mode:'cors'}).then(r=>r.json()).catch(()=>({ok:false})) ]); if(rc.ok&&rc.clientes&&rc.clientes.length){ clientes=rc.clientes; localStorage.setItem('factusol_clientes',JSON.stringify(clientes)); } if(ra.ok&&ra.articulos&&ra.articulos.length){ articulos=normalizarArticulosFactusol(ra.articulos); localStorage.setItem('factusol_articulos',JSON.stringify(articulos)); localStorage.setItem('factusol_cache_ts',String(Date.now())); } setPill('ok',clientes.length+' clientes · '+articulos.length+' arts.'); renderPage(); }catch(e){ setPill('err','Error al actualizar'); console.warn('actualizarFactusol error:',e); }finally{ if(btn){ btn.style.animation=''; btn.disabled=false; } } } async function cargarDatos(){ const timer=setTimeout(()=>{renderPage();},10000); // ── 1. Factusol: clientes y artículos (con caché 4h) ──────── try{ const CACHE_MS = 4 * 60 * 60 * 1000; // 4 horas const cacheTS = parseInt(localStorage.getItem('factusol_cache_ts')||'0'); const cacheExpirada = (Date.now() - cacheTS) > CACHE_MS; // Cargar caché inmediatamente (arranque instantáneo) const cCli = localStorage.getItem('factusol_clientes'); const cArt = localStorage.getItem('factusol_articulos'); if(cCli) clientes = JSON.parse(cCli); if(cArt) articulos = JSON.parse(cArt); if(clientes.length > 0){ apiOk = true; console.log('Factusol caché: '+clientes.length+' clientes, '+articulos.length+' arts.'); } // Recargar si la caché expiró O si no hay artículos if(cacheExpirada || clientes.length === 0 || articulos.length === 0){ console.log('Actualizando Factusol en segundo plano...'); Promise.all([ fetch(API+'/api/clientes',{mode:'cors'}).then(r=>r.json()).catch(()=>({ok:false})), fetch(API+'/api/articulos',{mode:'cors'}).then(r=>r.json()).catch(()=>({ok:false})) ]).then(([rc,ra])=>{ let updated = false; if(rc.ok&&rc.clientes&&rc.clientes.length){ clientes=rc.clientes; localStorage.setItem('factusol_clientes', JSON.stringify(clientes)); updated = true; } if(ra.ok&&ra.articulos&&ra.articulos.length){ articulos=normalizarArticulosFactusol(ra.articulos); localStorage.setItem('factusol_articulos', JSON.stringify(articulos)); localStorage.setItem('factusol_cache_ts', String(Date.now())); updated = true; } if(updated){ apiOk = true; setPill('ok', clientes.length+' clientes · '+articulos.length+' arts.'); console.log('Factusol actualizado: '+clientes.length+' clientes, '+articulos.length+' arts.'); if(currentPage==='articulos'||currentPage==='clientes') renderPage(); } }).catch(e=>console.warn('Error Factusol:',e.message)); } }catch(e){ console.warn('Error Factusol:',e.message); apiOk=false; } // ── 2. Supabase: partes, avisos y equipos ──────────────────── try{ const [partesDB,avisosDB]=await Promise.all([ SupaPartes.getAll(), SupaAvisos.getAll() ]); partes=partesDB; avisos=avisosDB; console.log('Supabase: '+partes.length+' partes, '+avisos.length+' avisos'); // Cargar equipos de clientes relevantes const cods=[...new Set([ ...partes.map(p=>p.clienteCod), ...avisos.map(a=>a.clienteCod) ].filter(Boolean))]; await Promise.all(cods.map(async cod=>{ const eqs=await SupaEquipos.getPorCliente(cod); equipos[cod]=eqs.map(e=>({ id:e.id,nombre:e.nombre,marca:e.marca,modelo:e.modelo, serie:e.serie,año:e.anio,ubic:e.ubicacion,obs:e.obs,foto:e.foto_url })); })); // Cache offline localStorage.setItem('frimay_partes_cache',JSON.stringify(partes)); localStorage.setItem('frimay_avisos_cache',JSON.stringify(avisos)); }catch(e){ console.warn('Error Supabase:',e.message); // Fallback a cache local const pc=localStorage.getItem('frimay_partes_cache'); const ac=localStorage.getItem('frimay_avisos_cache'); if(pc) try{partes=JSON.parse(pc);}catch(_){} if(ac) try{avisos=JSON.parse(ac);}catch(_){} } // ── 3. Actualizar UI ───────────────────────────────────────── setPill(apiOk?'ok':'err', apiOk ? clientes.length+' clientes · '+articulos.length+' arts.' : 'Sin conexión Factusol'); clearTimeout(timer); renderPage(); } function setPill(s,t){ const p=document.getElementById('api-pill'); p.className='api-chip '+s; p.innerHTML=`
${t}`; } function nav(page){ currentPage=page; document.querySelectorAll('.sb-item').forEach(n=>n.classList.remove('active')); document.getElementById('nav-'+page)?.classList.add('active'); mobNav(page); renderPage(); } function mobNav(page){ document.querySelectorAll('.mob-item').forEach(m=>m.classList.remove('active')); const mob=document.getElementById('mob-'+page); if(mob) mob.classList.add('active'); const pend=partes.filter(p=>p.estado==='pendiente').length; const mb=document.getElementById('mob-badge-p'); if(mb){mb.textContent=pend;mb.style.display=pend?'':'none';} } function renderPage(){ const pend=partes.filter(p=>p.estado==='pendiente').length; const bp=document.getElementById('badge-p'); if(bp){bp.textContent=pend;bp.style.display=pend?'':'none';} const avisosNew=avisos.filter(a=>a.estado==='nuevo'||a.estado==='pendiente').length; const bav=document.getElementById('badge-av'); if(bav){bav.textContent=avisosNew;bav.style.display=avisosNew?'':'none';} const bavmob=document.getElementById('mob-badge-av'); if(bavmob){bavmob.textContent=avisosNew;bavmob.style.display=avisosNew?'':'none';} const subs={dashboard:'Vista general del sistema',avisos:'Avisos de los clientes',partes:'Historial de partes de trabajo',clientes:'Base de datos de Factusol',articulos:'Catálogo sincronizado con Factusol',config:'Ajustes y tarifas',mapa:'Geolocalización de averías'}; const titles={dashboard:'Dashboard',avisos:'Avisos',partes:'Partes',clientes:'Clientes',articulos:'Artículos',config:'Configuración',mapa:'Mapa de averías'}; document.getElementById('page-title').textContent=titles[currentPage]||''; document.getElementById('page-sub').textContent=subs[currentPage]||''; document.getElementById('topbar-actions').innerHTML=''; const c=document.getElementById('content'); if(currentPage==='dashboard') pgDashboard(c); else if(currentPage==='avisos') pgAvisos(c); else if(currentPage==='partes') pgPartes(c); else if(currentPage==='clientes') pgClientes(c); else if(currentPage==='articulos') pgArticulos(c); else if(currentPage==='config') pgConfig(c); else if(currentPage==='mapa') pgMapa(c); } function addBtn(txt,cls,fn){ const b=document.createElement('button'); b.className='btn '+cls;b.innerHTML=txt;b.onclick=fn; document.getElementById('topbar-actions').appendChild(b); } /* ── DASHBOARD ── */ function getEstados(){ const def=[ {nombre:'pendiente',color:'#c47d11',icono:'⏳'}, {nombre:'en progreso',color:'#2563a8',icono:'⚙'}, {nombre:'cerrado',color:'#2e7d32',icono:'✓'}, ]; return JSON.parse(localStorage.getItem('cfg-estados')||JSON.stringify(def)); } function badgeEstado(estado){ const estados=getEstados(); const e=estados.find(x=>x.nombre===estado)||{nombre:estado,color:'#888',icono:'●'}; return ` ${e.nombre} `; } function pgDashboard(c){ const estados=getEstados(); const tot=partes.reduce((s,p)=>s+calcTotal(p),0); // Contar por cada estado const conteos=estados.map(e=>({...e,count:partes.filter(p=>p.estado===e.nombre).length})); // Estados desconocidos const otros=partes.filter(p=>!estados.find(e=>e.nombre===p.estado)); c.innerHTML=`
${conteos.map((e,i)=>`
${e.icono}
${e.count}
${e.nombre}
`).join('')}
€
${tot.toFixed(0)}
Total facturado
Últimos partes
Los 10 más recientes
${partes.length===0?`
📋
Sin partes todavía
Crea tu primer parte de trabajo
`: `
${partes.slice(-10).reverse().map(p=>``).join('')}
NºClienteEquipoTécnicoFechaEstadoTotal
#${p.numero} ${p.clienteNombre||'—'} ${p.equipoNombre||'—'} ${p.tecnico||'—'} ${fdate(p.fecha)} ${badgeEstado(p.estado)} ${calcTotal(p).toFixed(2)}€
`}
`; addBtn(' Nuevo parte','btn-primary',()=>abrirNuevoParte()); } function pgPartes(c){ c.innerHTML=`
${partes.length} partes registrados
${partes.length===0?`
◧
Sin partes
`: `
${partes.slice().reverse().map(p=>``).join('')}
NºClienteEquipoFechaTécnicoTipoEstadoTotal
#${p.numero} ${p.clienteNombre||'—'} ${p.equipoNombre||'—'} ${fdate(p.fecha)} ${p.tecnico||'—'} ${p.tipo||'—'} ${p.urgencia==='Urgente'?'!':''} ${badgeEstado(p.estado)} ${calcTotal(p).toFixed(2)}€
`}
`; addBtn('+ Nuevo parte','btn-primary',()=>abrirNuevoParte()); } /* ── CLIENTES ── */ function pgClientes(c){ const lista=clientes.filter(cl=>{ if(!_cliBusq)return true; const q=_cliBusq.toLowerCase(); return (cl.nombreFiscal||'').toLowerCase().includes(q)||(cl.nombreComercial||'').toLowerCase().includes(q)||String(cl.codigo).includes(q)||(cl.nif||'').toLowerCase().includes(q); }); c.innerHTML=`
⌕
${lista.length} resultado${lista.length!==1?'s':''}
${lista.slice(0,200).map(cl=>`
${ini(cl.nombreComercial||cl.nombreFiscal)}
${cl.nombreComercial||cl.nombreFiscal||'Sin nombre'}
${cl.nombreComercial?`${cl.nombreFiscal} · `:''}${cl.codigo}
`).join('')} ${lista.length>200?`
Mostrando 200 de ${lista.length}
`:''}
${_cliSel?'':emptyDetail()}
`; if(_cliSel)selCli(_cliSel,true); } function emptyDetail(){ return`
◉
Selecciona un cliente
Para ver su ficha completa
`; } function selCli(cod,keep){ _cliSel=cod; document.querySelectorAll('.cli-row').forEach(r=>r.classList.remove('selected')); const row=document.getElementById('cr-'+cod); if(row){row.classList.add('selected');if(!keep)row.scrollIntoView({block:'nearest'});} const cl=clientes.find(c=>c.codigo==cod)||{}; const eqs=equipos[cod]||[]; const det=document.getElementById('cli-detail'); if(!det)return; const fr=(k,v,full)=>v?`
${k}
${v}
`:''; // Función para generar URL Maps function mapsUrl(cl){ const dir=[cl.domicilio,cl.cp,cl.poblacion,cl.provincia,cl.pais].filter(Boolean).join(', '); return dir?encodeURIComponent(dir):''; } const mapsQ=mapsUrl(cl); det.innerHTML=`
${ini(cl.nombreComercial||cl.nombreFiscal)}
${cl.nombreComercial||cl.nombreFiscal||'—'}
${cl.nombreComercial?`
${cl.nombreFiscal}
`:''}
Cód. ${cl.codigo} ${cl.nif?`${cl.nif}`:''} ${cl.tarifa?`Tarifa ${cl.tarifa}`:''}
Dirección y contacto
${fr('Domicilio',cl.domicilio,true)} ${fr('Población / C.P.',cl.poblacion?(cl.cp?cl.cp+' — ':'')+cl.poblacion:cl.cp)} ${fr('Provincia',cl.provincia)} ${fr('País',cl.pais)} ${fr('Teléfono',cl.telefono?`${cl.telefono}`:'')} ${fr('Email',cl.email?`${cl.email}`:'')} ${fr('Web',cl.web?`${cl.web}`:'')} ${fr('Fax',cl.fax)}
${[cl.contacto1,cl.contacto2,cl.contacto3].some(x=>x?.nombre)?`
Personas de contacto
${[cl.contacto1,cl.contacto2,cl.contacto3].filter(x=>x?.nombre).map((ct,i)=>`
${i+1}
${ct.nombre}
${[ct.telefono,ct.email].filter(Boolean).join(' · ')}
`).join('')}`:''} ${mapsQ?`
Ubicación
Cómo llegar ${[cl.domicilio,cl.poblacion,cl.provincia].filter(Boolean).join(', ')}
`:''} ${cl.memo||cl.observaciones?`
Notas
${cl.memo?`
${cl.memo}
`:''} ${cl.observaciones?`
${cl.observaciones}
`:''}`:''}
Equipos instalados (${eqs.length})
${eqs.length===0?`
Sin equipos registrados
`: `
${eqs.map((e,i)=>`
${e.foto?``:'⚙'}
${e.nombre}
${[e.marca,e.modelo].filter(Boolean).join(' ')}
S/N: ${e.serie||'—'}
`).join('')}
`}
`; } /* ── ARTÍCULOS ── */ function pgArticulos(c){ const lista=articulos.filter(a=>{ if(!_artBusq)return true; const q=_artBusq.toLowerCase(); return (a.descripcion||'').toLowerCase().includes(q)||(a.codigo||'').toLowerCase().includes(q)||(a.codigoCorto||'').toLowerCase().includes(q); }); const IMG='https://frimaypartesdetrabajo-production.up.railway.app/api/imagen/'; c.innerHTML=`
${articulos.length} artículos en catálogo
${lista.length} resultado${lista.length!==1?'s':''}
${_artVista==='lista'?`
${lista.slice(0,400).map(a=>`
${a.imagen?``:'📦'}
${a.descripcion}
${a.codigo}${a.codigoCorto?' · '+a.codigoCorto:''}
${(a.pvp||a.precio).toFixed(2)} €
`).join('')} ${lista.length>400?`
Mostrando 400 de ${lista.length} — usa el buscador
`:''}
`:`
${lista.slice(0,300).map(a=>`
${a.imagen?``:'📦'}
${a.descripcion}
${a.codigo}${a.codigoCorto?' · '+a.codigoCorto:''}
${(a.pvp||a.precio).toFixed(2)} €
`).join('')} ${lista.length>300?`
Mostrando 300 de ${lista.length} — usa el buscador
`:''}
`}
`; } let _artVista='lista'; function showPreview(e,img){ if(!img)return; const el=document.getElementById('img-preview'); if(!el)return; el.src='https://frimaypartesdetrabajo-production.up.railway.app/api/imagen/'+img; el.style.display='block'; movePreview(e); document.addEventListener('mousemove',movePreview); } function movePreview(e){ const el=document.getElementById('img-preview'); if(!el||el.style.display==='none')return; const vw=window.innerWidth,vh=window.innerHeight; const w=el.offsetWidth||200,h=el.offsetHeight||200; el.style.left=(e.clientX+24+w>vw?e.clientX-w-12:e.clientX+16)+'px'; el.style.top=(e.clientY+h+10>vh?e.clientY-h-10:e.clientY+10)+'px'; } function hidePreview(){ const el=document.getElementById('img-preview'); if(el){el.style.display='none';el.src='';} document.removeEventListener('mousemove',movePreview); } function pgConfig(c){ const estados=getEstados(); c.innerHTML=`
Conexión Factusol
Servidor
https://frimaypartesdetrabajo-production.up.railway.app
Estado
${apiOk?'● Conectado':'● Sin conexión'}
Clientes
${clientes.length}
Artículos
${articulos.length}
Estados de los partes
${estados.map((e,i)=>`
${e.icono} ${e.nombre}
${estados.length>1?``:''}
`).join('')}

Arrastra el color para cambiar el tono. Los cambios se guardan automáticamente.

Tarifas y técnicos
`; } function updateEstado(idx,campo,val){ const estados=getEstados(); estados[idx][campo]=val; localStorage.setItem('cfg-estados',JSON.stringify(estados)); // Refrescar preview inline const row=document.getElementById('erow-'+idx); if(row){ const badge=row.querySelector('.badge-estado'); const color=estados[idx].color; if(badge){ badge.style.background=color+'18'; badge.style.color=color; badge.style.borderColor=color+'40'; badge.textContent=estados[idx].icono+' '+estados[idx].nombre; } } } function addEstado(){ const estados=getEstados(); const colores=['#8e24aa','#e53935','#039be5','#00897b','#f4511e','#3949ab']; const col=colores[estados.length%colores.length]; estados.push({nombre:'Nuevo estado',color:col,icono:'📌'}); localStorage.setItem('cfg-estados',JSON.stringify(estados)); pgConfig(document.getElementById('content')); } function delEstado(idx){ if(!confirm('¿Eliminar este estado?'))return; const estados=getEstados(); estados.splice(idx,1); localStorage.setItem('cfg-estados',JSON.stringify(estados)); pgConfig(document.getElementById('content')); } function abrirNuevoParte(codCli,eqIdx){ window._np={cli:codCli||null,eqIdx:eqIdx!=null?eqIdx:null,data:{repuestos:[],manoObra:[],desplazamiento:parseFloat(localStorage.getItem('cfg-desp')||25),estado:'pendiente'}}; const mo=mkOverlay('modal-parte'); mo.innerHTML=`

Nuevo parte de trabajo

${['1. Cliente','2. Diagnóstico','3. Repuestos','4. M. Obra','5. Cierre'].map((t,i)=>`
${t}
`).join('')}
`; document.body.appendChild(mo);pTab(1); } function pTab(n){ // Guardar datos del tab actual antes de cambiar const _cur=window._pTabActual||0; if(_cur===1) sP1(); else if(_cur===2) sP2(); else if(_cur===4) sP4(); else if(_cur===5) sP5(); window._pTabActual=n; document.querySelectorAll('.tab[id^="ptab"]').forEach((t,i)=>t.classList.toggle('active',i+1===n)); const b=document.getElementById('pb'),d=window._np.data; const tecs=(localStorage.getItem('cfg-tecnicos')||'Técnico 1').split(',').map(t=>t.trim()).filter(Boolean); if(n===1){ const codCli=window._np.cli; const eqs=codCli?(equipos[codCli]||[]):[]; b.innerHTML=`
Cliente y equipo
⌕
${window._np&&window._np.editId&&codCli?`
✓ Cliente seleccionado — borra el texto arriba para cambiar
`:``}
${clientes.slice(0,30).map(cl=>`
${ini(cl.nombreComercial||cl.nombreFiscal)}
${cl.nombreComercial||cl.nombreFiscal}
${cl.codigo}
`).join('')}
Datos del parte
`; setTimeout(()=>{ // Marcar cliente activo si existe if(codCli){ const el=document.getElementById('npc-'+codCli); if(el)el.classList.add('selected'); } const lista=document.getElementById('np-cl'); const inp=document.getElementById('np-cs'); if(window._np&&window._np.editId&&codCli){ // Edición con cliente: input readonly + clic para cambiar if(lista)lista.style.display='none'; if(inp){ inp.setAttribute('readonly','readonly'); inp.style.cursor='pointer'; inp.style.background='var(--red-soft)'; inp.title='Haz clic para buscar otro cliente'; inp.addEventListener('click',function(){ this.removeAttribute('readonly'); this.style.cursor='text'; this.style.background=''; this.value=''; if(lista){lista.style.display='';filtNpCli('');} this.focus(); },{once:true}); } } else { // Nuevo parte o edición sin cliente: ocultar lista hasta que escriban if(lista)lista.style.display='none'; if(inp){ inp.addEventListener('input',function(){ if(lista)lista.style.display=this.value.trim()?'':'none'; }); // Si ya hay texto (raro), mostrar lista if(inp.value.trim()&&lista)lista.style.display=''; } } },50); }else if(n===2){ b.innerHTML=`
Diagnóstico
Adjunta aquí los archivos que te haya enviado el cliente.
`; _renderFotosGrid(); }else if(n===3){ const rep=d.repuestos||[]; b.innerHTML=`
Repuestos imputados
${rep.map((r,i)=>rRow(r,i)).join('')}
⌕
Total materiales${rep.reduce((s,r)=>s+r.precio*(r.cantidad||1),0).toFixed(2)} €
`; }else if(n===4){ const mo2=d.manoObra||[]; const th=parseFloat(localStorage.getItem('cfg-hora')||45); const thu=parseFloat(localStorage.getItem('cfg-hora-urg')||65); b.innerHTML=`
Mano de obra
${mo2.map((m,i)=>mRow(m,i)).join('')}
Desplazamiento
Mano de obra0.00 €
Desplazamiento0.00 €
Materiales${(d.repuestos||[]).reduce((s,r)=>s+r.precio*(r.cantidad||1),0).toFixed(2)} €
TOTAL PARTE0.00 €
`; uT(); }else if(n===5){ b.innerHTML=`
✍ Firmar aquí
TOTAL PARTE${calcTotal(d).toFixed(2)} €
`; iF(); } } function filtNpCli(q){ const l=clientes.filter(c=>(c.nombreFiscal||'').toLowerCase().includes(q.toLowerCase())||(c.nombreComercial||'').toLowerCase().includes(q.toLowerCase())||String(c.codigo).includes(q)).slice(0,50); document.getElementById('np-cl').innerHTML=l.map(cl=>`
${ini(cl.nombreComercial||cl.nombreFiscal)}
${cl.nombreComercial||cl.nombreFiscal}
${cl.codigo}
`).join(''); } function selNpCli(cod){ window._np.cli=cod; const cl=clientes.find(c=>c.codigo==cod)||{}; document.getElementById('np-cs').value=cl.nombreComercial||cl.nombreFiscal||''; document.querySelectorAll('[id^="npc-"]').forEach(r=>r.classList.remove('selected')); const el=document.getElementById('npc-'+cod);if(el)el.classList.add('selected'); const eqs=equipos[cod]||[]; const sel=document.getElementById('np-eq'); if(sel)sel.innerHTML=`${eqs.map((e,i)=>``).join('')}`; } const rRow=(r,i)=>`
${r.imagen?``:'◈'}
${r.nombre}
Cant.:
Precio €:
${(r.precio*(r.cantidad||1)).toFixed(2)}€
` const mRow=(m,i)=>`
${m.tipo} — ${m.horas}h × ${m.precio}€/h
${(m.horas*m.precio).toFixed(2)}€
`; // Art seleccionado actualmente window._artSel=null; function filtArt(q){ const res=document.getElementById('art-drop'); const info=document.getElementById('art-sel-info'); if(!q||q.length<2){res.style.display='none';return;} const ql=q.toLowerCase(); const lista=articulos.filter(a=> (a.codigo||'').toLowerCase().includes(ql)|| (a.codigoCorto||'').toLowerCase().includes(ql)|| (a.descripcion||'').toLowerCase().includes(ql) ).slice(0,40); if(!lista.length){res.style.display='none';return;} res.style.display='block'; res.innerHTML=lista.map(a=>`
${a.imagen ? '' : '◈'}
${a.descripcion}
${a.codigo}${a.codigoCorto?' · '+a.codigoCorto:''}
${(a.pvp||a.precio).toFixed(2)}€
`).join(''); } function selArt(el){ const cod = el.dataset.cod; const nom = el.dataset.nom; const precio= parseFloat(el.dataset.pvp)||0; const imagen= el.dataset.img||''; window._np.data.repuestos.push({codigo:cod,nombre:nom,precio:precio,cantidad:1,imagen:imagen}); document.getElementById('rl').innerHTML=window._np.data.repuestos.map((r,i)=>rRow(r,i)).join(''); uRt(); document.getElementById('art-busq').value=''; document.getElementById('art-drop').style.display='none'; } function addR(){ if(!window._artSel){alert('Selecciona un artículo del buscador');return;} const a=window._artSel; const precioInp=document.getElementById('art-sel-precio'); const precioFinal=precioInp?parseFloat(precioInp.value)||a.precio:a.precio; window._np.data.repuestos.push({codigo:a.codigo,nombre:a.nombre,precio:precioFinal,cantidad:1}); document.getElementById('rl').innerHTML=window._np.data.repuestos.map((r,i)=>rRow(r,i)).join(''); uRt(); // Limpiar selección window._artSel=null; document.getElementById('art-busq').value=''; document.getElementById('art-sel-info').style.display='none'; document.getElementById('art-drop').style.display='none'; } function dR(i){window._np.data.repuestos.splice(i,1);document.getElementById('rl').innerHTML=window._np.data.repuestos.map((r,i)=>rRow(r,i)).join('');uRt();} function uRt(){const t=window._np.data.repuestos.reduce((s,r)=>s+r.precio*(r.cantidad||1),0);const e=document.getElementById('rt');if(e)e.textContent=t.toFixed(2)+' €';} function addM(){const t=document.getElementById('mt');const h=parseFloat(document.getElementById('mh').value)||1;const p=parseFloat(t.value);const l=t.options[t.selectedIndex].text.split(' ')[0];window._np.data.manoObra.push({tipo:l,horas:h,precio:p});document.getElementById('ml').innerHTML=window._np.data.manoObra.map((m,i)=>mRow(m,i)).join('');uT();} function dM(i){window._np.data.manoObra.splice(i,1);document.getElementById('ml').innerHTML=window._np.data.manoObra.map((m,i)=>mRow(m,i)).join('');uT();} function cD(){ const nk=document.getElementById('nk'); const km=parseFloat(nk?.value)||0; const b=parseFloat(localStorage.getItem('cfg-desp')||25); const pk=parseFloat(localStorage.getItem('cfg-km')||0.35); const desp=b+km*pk; const nd=document.getElementById('nd'); if(nd) nd.value=desp.toFixed(2); if(window._np?.data){ window._np.data.km=km; window._np.data.desplazamiento=desp; } uT(); } function uT(){ const mo=(window._np.data.manoObra||[]).reduce((s,m)=>s+m.horas*m.precio,0); const d=parseFloat(document.getElementById('nd')?.value||0); const r=(window._np.data.repuestos||[]).reduce((s,r)=>s+r.precio*(r.cantidad||1),0); window._np.data.desplazamiento=d; const e1=document.getElementById('mot');if(e1)e1.textContent=mo.toFixed(2)+' €'; const e2=document.getElementById('det');if(e2)e2.textContent=d.toFixed(2)+' €'; const e3=document.getElementById('tt');if(e3)e3.textContent=(mo+d+r).toFixed(2)+' €'; } function sP1(){ const d=window._np.data; d.clienteCod=window._np.cli; const tec=document.getElementById('np-tec'); const fecha=document.getElementById('np-fecha'); const tipo=document.getElementById('np-tipo'); const urg=document.getElementById('np-urg'); const eq=document.getElementById('np-eq'); // Solo guardar si el campo existe en el DOM if(tec!==null) d.tecnico=tec.value; if(fecha!==null) d.fecha=fecha.value; if(tipo!==null) d.tipo=tipo.value; if(urg!==null) d.urgencia=urg.value; if(eq!==null&&eq.value&&eq.value!=='nuevo') window._np.eqIdx=parseInt(eq.value); } function sP4(){ const nk=document.getElementById('nk'); const nd=document.getElementById('nd'); if(nk!==null&&window._np?.data){ window._np.data.km=parseFloat(nk.value)||0; } if(nd!==null&&window._np?.data){ window._np.data.desplazamiento=parseFloat(nd.value)||0; } } function sP5(){ const no=document.getElementById('no'); const ne=document.getElementById('ne'); if(no!==null&&window._np?.data) window._np.data.obs=no.value; if(ne!==null&&window._np?.data) window._np.data.estado=ne.value; } function sP2(){ const d=window._np.data; const prob=document.getElementById('np-prob'); const diag=document.getElementById('np-diag'); const trab=document.getElementById('np-trab'); // Solo guardar si el campo existe en el DOM (estamos en tab2) if(prob!==null) d.problema=prob.value; if(diag!==null) d.diagnostico=diag.value; if(trab!==null) d.trabajos=trab.value; } let fd=false,fc2=null,fce=null; function iF(){ const cv=document.getElementById('fc');if(!cv)return; fce=cv;fc2=cv.getContext('2d');fc2.strokeStyle='#4d8dff';fc2.lineWidth=2;fc2.lineCap='round'; const pos=(e,r)=>{const t=e.touches?e.touches[0]:e;return{x:(t.clientX-r.left)*(cv.width/r.width),y:(t.clientY-r.top)*(cv.height/r.height)};}; cv.addEventListener('mousedown',e=>{fd=true;const p=pos(e,cv.getBoundingClientRect());fc2.beginPath();fc2.moveTo(p.x,p.y);document.getElementById('fph').style.display='none';}); cv.addEventListener('mousemove',e=>{if(!fd)return;const p=pos(e,cv.getBoundingClientRect());fc2.lineTo(p.x,p.y);fc2.stroke();}); cv.addEventListener('mouseup',()=>{ fd=false; if(fce){ window._np.data.firma=fce.toDataURL(); _marcarFirmado(); } }); cv.addEventListener('touchstart',e=>{e.preventDefault();fd=true;const p=pos(e,cv.getBoundingClientRect());fc2.beginPath();fc2.moveTo(p.x,p.y);document.getElementById('fph').style.display='none';},{passive:false}); cv.addEventListener('touchmove',e=>{e.preventDefault();if(!fd)return;const p=pos(e,cv.getBoundingClientRect());fc2.lineTo(p.x,p.y);fc2.stroke();},{passive:false}); cv.addEventListener('touchend',()=>{ fd=false; if(fce){ window._np.data.firma=fce.toDataURL(); _marcarFirmado(); } }); } function bF(){if(fc2&&fce){fc2.clearRect(0,0,fce.width,fce.height);window._np.data.firma=null;const p=document.getElementById('fph');if(p)p.style.display='';}} function gP(){ sP1();sP2();sP4();sP5(); const d=window._np.data; const cl=clientes.find(c=>c.codigo==d.clienteCod)||{}; const eqs=equipos[d.clienteCod]||[]; const eq=window._np.eqIdx!=null?eqs[window._np.eqIdx]:null; const nkEl=document.getElementById('nk'); const ndEl=document.getElementById('nd'); if(nkEl) d.km=parseFloat(nkEl.value)||0; if(ndEl) d.desplazamiento=parseFloat(ndEl.value)||0; const noEl=document.getElementById('no'); const neEl=document.getElementById('ne'); if(noEl) d.obs=noEl.value; if(neEl) d.estado=neEl.value; const parteData={ avisoId:window._np.avisoId||null, clienteCod:d.clienteCod, clienteNombre:cl.nombreComercial||cl.nombreFiscal||'—', equipoNombre:eq?.nombre||'—', equipoId:eq?.id||null, tecnico:d.tecnico, fecha:d.fecha, tipo:d.tipo, problema:d.problema, trabajos:d.trabajos, repuestos:d.repuestos||[], manoObra:d.manoObra||[], desplazamiento:d.desplazamiento||0, km:d.km||0, obs:d.obs||'', estado:d.estado||'pendiente', firma:d.firma||null, firmaGeo:d.firmaGeo||null, fotosCliente:d.fotosCliente||[] }; // Mostrar spinner const saveBtn=document.querySelector('[onclick="gP()"]'); if(saveBtn){saveBtn.disabled=true;saveBtn.textContent='Guardando...';} if(window._np.editId){ // EDITAR en Supabase SupaPartes.actualizar(window._np.editId, parteData).then(async ()=>{ // Actualizar en memoria const idx=partes.findIndex(x=>x.id===window._np.editId); if(idx>=0){ const updated=await SupaPartes.getById(window._np.editId); partes[idx]=updated; } // Vincular aviso si existe if(window._np.avisoId){ const estados=getEstados(); const estCurso=estados.find(e=>e.nombre.toLowerCase().includes('curso'))||estados[1]||estados[0]; const avIdx=avisos.findIndex(x=>x.id===window._np.avisoId); if(avIdx>=0){ avisos[avIdx].estado=estCurso.nombre; await SupaAvisos.actualizar(window._np.avisoId,{...avisos[avIdx]}); } } cerrar('modal-parte'); renderPage(); if(currentPage==='partes'||currentPage==='dashboard'){ const cont=document.getElementById('content'); if(cont){if(currentPage==='partes')pgPartes(cont);else pgDashboard(cont);} } verParte(window._np.editId); }).catch(e=>{ alert('Error guardando: '+e.message); if(saveBtn){saveBtn.disabled=false;saveBtn.textContent='Guardar';} }); } else { // CREAR en Supabase SupaPartes.crear(parteData).then(async (newId)=>{ // Actualizar aviso si existe if(window._np.avisoId){ const estados=getEstados(); const estCurso=estados.find(e=>e.nombre.toLowerCase().includes('curso'))||estados[1]||estados[0]; await SupaAvisos.vincularParte(window._np.avisoId, newId, estCurso.nombre); const avIdx=avisos.findIndex(x=>x.id===window._np.avisoId); if(avIdx>=0){avisos[avIdx].estado=estCurso.nombre;avisos[avIdx].parteId=newId;} } // Recargar partes const nuevoParte=await SupaPartes.getById(newId); partes.unshift(nuevoParte); cerrar('modal-parte'); nav('partes'); }).catch(e=>{ alert('Error guardando: '+e.message); if(saveBtn){saveBtn.disabled=false;saveBtn.textContent='Guardar';} }); } } function verParte(id){ const p=partes.find(x=>x.id===id);if(!p)return; // Las fotos ya vienen de Supabase en el objeto p // Solo combinar con IDB si hay fotos locales adicionales cargarFotosIDB(id).then(fotos=>{ const pConFotos={...p}; // Solo usar IDB si tiene fotos (no sobreescribir las de Supabase) if(fotos&&fotos.fotosCliente&&fotos.fotosCliente.length) pConFotos.fotosCliente=fotos.fotosCliente; if(fotos&&fotos.fotosReparacion&&fotos.fotosReparacion.length) pConFotos.fotosReparacion=fotos.fotosReparacion; _verParteConFotos(pConFotos); }).catch(()=>_verParteConFotos(p)); } function _verParteConFotos(p){ const tot=calcTotal(p); const mo=mkOverlay('modal-ver'); mo.innerHTML=`
#${p.numero}

Parte #${p.numero}

${p.clienteNombre} · ${fdate(p.fecha)}
${badgeEstado(p.estado)}
Cliente
${p.clienteNombre}
Equipo
${p.equipoNombre}
Técnico
${p.tecnico||'—'}
Fecha
${fdate(p.fecha)} ${p.urgencia==='Urgente'?'URGENTE':''}
Tipo
${p.tipo||'—'}
Estado
${p.estado}
${p.problema?`
Problema
${p.problema}
`:''} ${p.diagnostico?`
Diagnóstico
${p.diagnostico}
`:''} ${p.trabajos?`
Trabajos
${p.trabajos}
`:''} ${p.repuestos?.length?`
Repuestos
${p.repuestos.map(r=>`
${r.nombre}${r.cantidad}×${r.precio.toFixed(2)}€
`).join('')}
`:''} ${p.manoObra?.length?`
Mano de obra
${p.manoObra.map(m=>`
${m.tipo} — ${m.horas}h${(m.horas*m.precio).toFixed(2)}€
`).join('')}
`:''}
Materiales${(p.repuestos||[]).reduce((s,r)=>s+r.precio*(r.cantidad||1),0).toFixed(2)} €
Mano de obra${(p.manoObra||[]).reduce((s,m)=>s+m.horas*m.precio,0).toFixed(2)} €
Desplazamiento${(p.desplazamiento||0).toFixed(2)} €
TOTAL${tot.toFixed(2)} €
${p.firma?`
Firma del cliente
${p.firmaGeo?`
📍 Ubicación registrada · ±${p.firmaGeo.acc||'?'}m de precisión 🗺 Google Maps